Repository navigation
feat(auth): registration, JWT login and logout, password reset - #2
Merged
Merged
Conversation
The auth module needs only the password encoders of Spring Security, not the whole security stack, so it depends on the small spring-security-crypto artifact. Its version comes from the Spring Security BOM already imported by the parent pom. Keeping the web-facing Spring Security modules in ws preserves the rule that core knows nothing about HTTP.
Services that compare or stamp times take a java.time.Clock instead of calling Instant.now(). The production bean is Clock.systemUTC(), and a test can build the service with a fixed clock to check expiry rules exactly. It is declared once in CommonConfig, next to the other infrastructure beans, so every module gets the same time source.
Entities are the raw, table-shaped view of a row: one class per table, annotated with jakarta.persistence @table, @id and @column. Those annotations are documentation only, since no JPA provider exists in this project, they simply declare the table and column mapping next to the class. User, ActivationToken, BlacklistedToken and PasswordResetToken are plain classes with explicit accessors and equals/hashCode. Anything carrying a password hash or a token digest masks it in toString so it never reaches a log.
DTOs are the business shape a service exposes, a genuinely separate class from the entity even when the fields match. UserDto carries the raw password on input only and always null on output, plus the role names and the RESOURCE:ACTION permission codes of the user. BlacklistedTokenDto describes a token to revoke at logout. All of them mask passwords and tokens in toString.
A RowMapper<T> is the JdbcTemplate callback that converts one ResultSet row into one object. Each mapper reads its columns by name and does nothing else: no query, no business rule. timestamptz columns are read with getTimestamp and converted to Instant through a small null-safe helper, because validated_at is NULL until an activation token is consumed.
The entity mapper is the only place where the two shapes are converted, in both directions. toDto never copies the password hash, and toEntity takes the already hashed password as an explicit argument, so a raw password can never be written into an entity by accident. Roles and permissions are not columns of users: the service fills them in from separate queries.
The DAO owns the SQL and works only in entity types. Lookups by email compare LOWER(email) to match the case-insensitive unique index, insert uses INSERT ... RETURNING id to get the generated key in one round trip, and the permission query joins role_user, role_permission and permissions to return distinct RESOURCE:ACTION codes. The implementation extends AbstractDao and has no stereotype annotation: it is declared by an explicit @bean in DaoConfig.
Activation and password reset tokens are looked up by their SHA-256 digest, which is the only form ever stored. The blacklist DAO only probes, inserts and purges: existsByTokenHash answers the per-request revocation check and deleteExpiredBefore backs the scheduled cleanup. All three run plain JdbcTemplate SQL through AbstractDao, bind Instants with java.sql.Timestamp and work only in entity types.
AuthenticationFailedException is the base of every 401 case, so the web layer maps one type. InvalidCredentialsException carries a single message for an unknown email and a wrong password, and AccountNotActiveException is raised only after the password was verified, so it never reveals whether an email exists. They live in core and know nothing about HTTP.
AuthServiceImpl holds the business rules and the transaction boundaries. @transactional makes a Spring proxy open, commit or roll back a transaction around each write, so a user row and its activation token succeed or fail together. Tokens are 256 bits of SecureRandom output stored only as SHA-256 digests, with the raw value visible only in an INFO log line because no mailer exists. Login gives one error for an unknown email and a wrong password and runs a fake bcrypt check for the unknown one to equalise timing. Passwords are limited to 72 bytes, the bcrypt limit, and forgotPassword behaves the same whether or not the account exists. Logout is idempotent, so a repeated revocation of the same token is accepted.
DaoConfig and ServiceConfig declare every bean explicitly and return them typed by interface, which is what lets @transactional apply through a JDK proxy. The BCryptPasswordEncoder is exposed as the PasswordEncoder abstraction. @EnableScheduling registers the post-processor that runs @scheduled methods: ExpiredTokenCleanupJob calls purgeExpiredTokens every day at 3 am to delete blacklist rows whose token has expired anyway. The method is public, so a test can call it directly.
ws now depends on spring-security-web and spring-security-config, the web-facing half of Spring Security, and on jjwt: the API at compile time, the implementation and Jackson binding at runtime only. Surefire receives the two JWT key locations as system properties pointing at the development key pair at the repository root, so the tests never read keys from the classpath.
A 2048 bit RSA pair (PKCS#8 private key, X.509 public key) for local development and the automated tests. It sits in dev-keys/ at the repository root, never under src/main/resources, so it can never be packaged inside the WAR. The README of the directory states they are public and dev only. Production keys are generated separately and mounted from a git-ignored directory, and the application has no default key at all.
A JWT is a signed, self-contained bearer credential, so the API keeps no session. RS256 is asymmetric: only the private key holder can issue tokens and verification needs just the public key. JwtKeyLoader reads both PEM files from configurable locations and fails startup with a clear message when one is missing or of the wrong type. Tokens carry the user id, a random jti, the email and the permission codes. parse never throws: a bad signature, an expired or foreign token, or any algorithm other than RS256 gives an empty result. The raw JWT is only ever handled through its SHA-256 digest.
… tokens A OncePerRequestFilter reads the Bearer token, verifies it and then asks AuthService whether its SHA-256 digest is blacklisted. A valid, non revoked token fills the SecurityContext with an AuthenticatedUser principal and one authority per permission code taken from the token claims, so no database call is needed to authorise a request. For a missing, invalid or revoked token the context stays empty and the entry point answers 401. The filter is created inside the SecurityFilterChain and is not registered as a servlet filter.
The filters of Spring Security run before the DispatcherServlet, so GlobalExceptionHandler never sees their failures. An AuthenticationEntryPoint handles unauthenticated requests and an AccessDeniedHandler handles authenticated requests lacking a right; both write an ApiResponse error as JSON with the shared ObjectMapper. Every error of the API therefore has the same shape, including those raised by the security layer.
@EnableWebSecurity registers the infrastructure and the springSecurityFilterChain filter. The chain is a plain @bean: CSRF is disabled because this is a token API without cookies, sessions are STATELESS, the register, activation, login, forgot and reset routes and the health check are public, everything else needs authentication, and JwtAuthFilter runs before UsernamePasswordAuthenticationFilter. The JwtService bean reads the key locations from the Environment with no default, so startup fails clearly without them. Method security is not enabled yet, the rbac branch adds it with the permission evaluator.
Payload classes are the HTTP request and response shapes, named differently from core dto classes on purpose. Requests carry Bean Validation constraints: an email format, and a password between 8 and 72 characters. The byte limit of bcrypt is still enforced by the service for multibyte input. UserConverter maps between the core UserDto and these payloads with static methods, so the controller never touches a dto directly. Passwords and tokens are masked in toString.
The controller depends only on AuthService and JwtService and holds no business rule: register, activate, login (authenticate then issue a JWT), logout (revoke the token of the authenticated principal), me, forgot and reset password. Every body is validated and every answer is an ApiResponse. forgot-password always answers with the same generic message, so the endpoint cannot be used to discover which emails are registered. @AuthenticationPrincipal injects the AuthenticatedUser built by the filter.
Exceptions thrown inside a controller reach the advice before the security filters, so an AccessDeniedException raised by method security would fall into the catch-all and become a 500. The advice now maps AuthenticationFailedException and Spring's AuthenticationException to 401 and AccessDeniedException to 403, all as ApiResponse. This fills the spot the core-architecture branch left open for it.
WebMvcConfig now imports the auth ServiceConfig, by fully qualified name since every core module has a ServiceConfig, and SecurityConfig. One ObjectMapper bean is shared by the MVC converter and the security handlers so all JSON has the same format. WebAppInitializer registers a DelegatingFilterProxy that looks up the springSecurityFilterChain bean in the root context, which is why a single shared context matters. It is registered after the character encoding filter.
The DAO tests of the auth module run against a real PostgreSQL, so the module declares the Testcontainers JUnit and PostgreSQL artifacts, and the common test-jar that holds the shared singleton container, all with test scope. They stay in this module rather than the parent pom because only modules with DAO tests need them.
Each DAO test class loads its own dedicated SQL fixture through @SQL before every test, which truncates the tables it touches with RESTART IDENTITY and inserts its own rows, so no dataset is shared between classes. UserDao tests cover case-insensitive lookups, generated keys, a duplicate email differing only by case, and the joins returning distinct sorted permission codes. The token DAO tests cover insert, lookup by digest, marking a token validated, deletion by user, and the purge counts of the blacklist.
Each RowMapper is fed a Mockito mock of java.sql.ResultSet stubbed column by column, so no database is involved, including the case of a NULL validated_at. UserMapper is tested with hand-built objects and no mocks: toDto never copies the password hash and toEntity uses the hash it is given. These are the fastest tests of the strategy and pin down the mapping contracts the DAO and service layers rely on.
… DAOs The service is tested with Mockito DAOs, a real low-cost bcrypt encoder and a fixed Clock so expiry rules are exact. The tests cover the 72-byte limit with multibyte characters, the digest-only storage of tokens, one identical error for an unknown email and a wrong password, a password over 72 bytes treated as invalid credentials, the inactive-account error only after a correct password, an idempotent logout and a forgotPassword that never throws. ExpiredTokenCleanupJobTest calls run() directly, which is why the scheduled method is public.
AuthTestSupport registers, activates and logs in a user through the real endpoints and returns the JWT, reading raw tokens from the INFO log with a logback ListAppender since no mailer exists. ApiAssertions checks the ApiResponse envelope of successes and errors, and JwtTestSupport builds the service on the development keys and forges tokens with other keys. They are meant to be reused by the controller tests of the later branches.
The tests cover the issue and parse round trip, unique jti values, a tampered signature or payload, expiry through a fixed Clock including the boundary, a wrong issuer, a foreign key, and the classic algorithm confusion attacks: HS256 signed with the public key, alg none and RS384. JwtKeyLoader is checked for a missing property, a PEM of the wrong type in both directions, an unreadable file and invalid Base64, each with a clear failure message.
The real SecurityConfig and a mocked AuthService are driven through MockMvc with springSecurity() applied. Every route is checked for status and ApiResponse shape, validation errors are checked per field, and the chain must answer 401 in the ApiResponse format for a missing, garbage, expired, wrongly signed, HS256, alg none or blacklisted token. forgot-password is asserted to return the same status and body for a known and an unknown email, and logout to build its BlacklistedTokenDto from the authenticated principal.
The integration test boots the real WebMvcConfig on a Testcontainers database and walks register, activate, login, /me, logout. The same token then gets 401 while a second token of the same user still gets 200, which proves the blacklist really rejects a revoked token. It also checks that only SHA-256 digests are stored, the reset flow, single-use tokens, identical answers for wrong password, unknown email and over-long password, and the cleanup of expired blacklist rows. The existing initializer and context tests are adapted to the new security filter and to the fact that an unknown URL is 401 without a token.
The app service now receives APP_JWT_PRIVATE_KEY_LOCATION and APP_JWT_PUBLIC_KEY_LOCATION, which Spring maps to app.jwt.* properties, and mounts ./keys read-only at /keys. There is no default key: without the mounted pair the application refuses to start. The README of the compose file now applies to the whole project since authentication exists. The keys directory is ignored by git.
normalizeEmail throws a BusinessRuleException for a blank value, so a whitespace-only email produced a 422 while every other input, known or unknown, gave the generic answer. forgotPassword now handles a null or blank email like an unknown account and never throws. The unit test suite gets a non-regression test for it, and the following test methods are renumbered to keep the sequence.
All nine tasks of the auth branch are checked. They cover the entities and dto classes, the RowMappers and UserMapper, the DAOs, AuthService with its constant-outcome forgotPassword, the module wiring, the JWT service and filter, the controller, the WebMvcConfig imports and the tests at all five layers plus the full register to login flow.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Branch
feature/auth
Task checklist
core/authentities and dto classes (User, ActivationToken, BlacklistedToken, PasswordResetToken)core/authDaoConfig and ServiceConfigws: JwtService, JwtAuthFilter, AuthController, UserConverterCommit summary
Deviations and limits are recorded in the project notes: no @EnableMethodSecurity yet (rbac), permissions live in the JWT until the next token, the cleanup only purges the blacklist.
Test plan
mvn clean verifyis green on the whole reactor